Skip to content

Conversation

Magnusrm
Copy link
Contributor

@Magnusrm Magnusrm commented Sep 2, 2025

Description

New component ImageUpload. The main functionality of this component is to let the user upload an image and crop it before saving it to the instance. When it is saved, user will be displayed the result.
Validations are added: the file must be of type image and the size can't exceed 10MB. If user adds an image type that can contain animation such as gif, apng or webp, we inform user that only the first frame is displayed.

The logic behind cropping the image, which is also used in other third-party libraries, uses the web API Canvas. It can draw the uploaded image according to our specifications, such as which part of the image should be drawn and kept inside the crop area.

The content of the crop area is determined by various utilities that calculate the position of the image (x, y) relative to the canvas, the size of the image relative to the canvas, the center of the crop area, clamped positions (restricting the user from dragging the crop area outside the image), etc. Most of these calculation functions can be found in the utility file.

This new component has some specific configuration options:

  • Users can choose between two types of crop shapes: cropShape: enum["square", "circle"], where "circle" is the default.
  • cropWidth: the width of the crop area in pixels. Default is 250px.
  • cropHeight: the height of the crop area in pixels. Default is also 250px.

These are optional props. If the user chooses not to set them, they will default to 250px and "circle".

Studio also supports this new component and is currently behind the feature flag: imageUpload.

This component also supports keyboard users.

Altinn docs is updated.

Here is how the component will behave for the user:

imageupload.mp4

Related Issue(s)

  • closes #{issue number}

Verification/QA

  • Manual functionality testing
    • I have tested these changes manually
    • Creator of the original issue (or service owner) has been contacted for manual testing (or will be contacted when released in alpha)
    • No testing done/necessary
  • Automated tests
    • Unit test(s) have been added/updated
    • Cypress E2E test(s) have been added/updated
    • No automatic tests are needed here (no functional changes/additions)
    • I want someone to help me make some tests
  • UU/WCAG (follow these guidelines until we have our own)
    • I have tested with a screen reader/keyboard navigation/automated wcag validator
    • No testing done/necessary (no DOM/visual changes)
    • I want someone to help me perform accessibility testing
  • User documentation @ altinn-studio-docs
  • Support in Altinn Studio
    • Issue(s) created for support in Studio
    • This change/feature does not require any changes to Altinn Studio
  • Sprint board
    • The original issue (or this PR itself) has been added to the Team Apps project and to the current sprint board
    • I don't have permissions to do that, please help me out
  • Labels
    • I have added a kind/* and backport* label to this PR for proper release notes grouping
    • I don't have permissions to add labels, please help me out

Summary by CodeRabbit

  • New Features

    • New Image upload component: drag-and-drop, file picker, cropping (circle/rectangle), zoom, reset/cancel/save, preview, delete and replace flows.
  • Improvements

    • Summary view now shows image preview when present.
    • Enhanced dropzone feedback and clearer prompts.
    • Updated translations (en/nb/nn), including "finn fil".
    • Adjusted card/media vertical spacing for tighter layouts.
  • Tests

    • Added unit tests and end-to-end tests covering upload, crop, save, delete, replace, and utilities.

Copy link

coderabbitai bot commented Sep 2, 2025

📝 Walkthrough

Walkthrough

Adds a new ImageUpload feature (components, hooks, utils, styles, tests), updates Dropzone API and FileUpload integration to accept file-type mappings, extends Card API for styling refs, adjusts card/media CSS, and adds localization keys and minor summary/option prop changes.

Changes

Cohort / File(s) Summary
ImageUpload feature – core components
src/layout/ImageUpload/index.tsx, src/layout/ImageUpload/config.ts, src/layout/ImageUpload/ImageUploadComponent.tsx, src/layout/ImageUpload/ImageUploadSummary2.tsx, src/layout/ImageUpload/ImageUploadSummary2.module.css, src/layout/ImageUpload/useImageFile.tsx
Introduces ImageUpload layout class, config, renderer, summary component, styles, and a hook managing stored image, URLs, save/delete operations.
ImageUpload – cropping workflow
src/layout/ImageUpload/ImageCropper.tsx, src/layout/ImageUpload/ImageControllers.tsx, src/layout/ImageUpload/ImageControllers.module.css, src/layout/ImageUpload/ImageDropzone.tsx, src/layout/ImageUpload/ImageDropzone.module.css
Adds ImageCropper, controller panel (zoom/reset/save/delete), and a dedicated dropzone plus associated styles and i18n wiring.
ImageUpload – canvas and interactions
src/layout/ImageUpload/ImageCanvas/ImageCanvas.tsx, src/layout/ImageUpload/ImageCanvas/ImageCanvas.module.css, src/layout/ImageUpload/ImageCanvas/ImagePreview.tsx, src/layout/ImageUpload/ImageCanvas/ImagePreview.module.css, src/layout/ImageUpload/ImageCanvas/hooks/useCanvasDraw.tsx, src/layout/ImageUpload/ImageCanvas/hooks/useDragInteraction.tsx, src/layout/ImageUpload/ImageCanvas/hooks/useKeyboardNavigation.tsx, src/layout/ImageUpload/ImageCanvas/hooks/useZoomInteraction.tsx
Implements canvas-based preview/cropping, preview component, and hooks for drawing, drag, keyboard navigation, and wheel zoom interactions with styles.
ImageUpload – utilities
src/layout/ImageUpload/imageUploadUtils.ts
Adds image/crop math, placement/drawing helpers, zoom conversions, validation (10 MB), allowed-types checks, animation detection, and filename helper.
ImageUpload – tests & E2E helpers
src/layout/ImageUpload/ImageUploadComponent.test.tsx, src/layout/ImageUpload/ImageUploadSummary2.test.tsx, src/layout/ImageUpload/imageUploadUtils.test.tsx, test/e2e/integration/component-library/image-upload.ts, test/e2e/support/apps/component-library/uploadImageAndVerify.ts
Adds unit and E2E tests for components, utilities, summary, and Cypress helpers to upload and verify canvas content.
Dropzone API update
src/app-components/Dropzone/Dropzone.tsx
Renames exported type to IDropzoneProps, makes onClick optional, adds onDragActiveChange, replaces custom-extension handling with acceptedFiles?: { [key: string]: string[] }, and triggers drag-active callbacks.
FileUpload integration updates
src/layout/FileUpload/FileUploadComponent.tsx, src/layout/FileUpload/FileUploadTable/FileTable.tsx, src/layout/FileUpload/utils/useFileUploaderDataBindingsValidation.ts, src/layout/FileUpload/FileUploadComponent.test.tsx
Adapts FileUpload to new Dropzone acceptedFiles, extends hooks/types to accept ImageUpload, updates FileTable to recognize ImageUpload, and tweaks test expectation text.
Localization additions
src/language/texts/en.ts, src/language/texts/nb.ts, src/language/texts/nn.ts
Adds image_upload_component.* keys and updates nb/nn translations for file-finding label.
Cards styling hook
src/layout/Cards/Cards.module.css, src/layout/Cards/Cards.tsx, src/app-components/Card/Card.module.css
Adds .mediaCard in layout Cards and applies it; removes negative bottom margin from app Card media CSS.
AppCard API extension
src/app-components/Card/Card.tsx
Adds optional className?: string and ref?: React.Ref<HTMLDivElement> to AppCard props and forwards them to underlying Card.
Expressions shared test
src/features/expressions/shared-tests/functions/displayValue/type-ImageUpload.json
Adds a displayValue shared test for ImageUpload mapping filename to display value.
Summary component API tweak
src/layout/Summary2/CommonSummaryComponents/SingleValueSummary.tsx
Expands displayData prop type from string to `string
Minor prop explicitness
src/layout/Option/OptionSummary.tsx
Makes hideEditButton={true} explicit.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Suggested reviewers

  • Magnusrm

Pre-merge checks and finishing touches

❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Description Check ⚠️ Warning The description follows the repository’s template with all major sections and checkboxes, but the Related Issue(s) section still contains the placeholder "#{issue number}" instead of an actual issue reference, leaving it incomplete. Please replace the placeholder in the Related Issue(s) section with the actual issue number (for example “closes #123”) or remove the section if no issue exists to ensure the template is complete.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (1 passed)
Check name Status Explanation
Title Check ✅ Passed The title clearly indicates the main feature being added, namely the image upload component, and is directly related to the changeset introducing that component.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/image-upload-component

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Magnusrm Magnusrm added kind/product-feature Pull requests containing new features backport-ignore This PR is a new feature and should not be cherry-picked onto release branches labels Sep 2, 2025
lassopicasso and others added 21 commits September 2, 2025 12:39
…tsimplify mouse dragging and support touch dragging (#3656)
* reuse dropzonecomponent and display only when imgsrc is not defined

* bring back the reset button

* move controller section to its own file
* move image cropper into card wip

* working cropping component in card

* conforrm to styling naming in repo

* canvas height adjustment

---------

Co-authored-by: lassopicasso <[email protected]>
* clean up css and style closer to figma design
…fn) (#3668)

* added save and cancel buttons with functionality (some left for save fn)

* Adding AttachmentsPlugin to ImageUpload component (it needs to be defined as a form component for this to work, which is more fitting)

* save cropped image to backend

* feedback

* small fix

---------

Co-authored-by: Ole Martin Handeland <[email protected]>
* added save and cancel buttons with functionality (some left for save fn)

* Adding AttachmentsPlugin to ImageUpload component (it needs to be defined as a form component for this to work, which is more fitting)

* save cropped image to backend

* redesign slider + buttons

---------

Co-authored-by: Ole Martin Handeland <[email protected]>
* wip

* revert unneccesary change

* clean up
* add hook that handles the saved image and controllers buttons when saved
* support custom config

* support mismatch of  height and width if type is circle
* remove dropzone icon and refactor css

* replace look of placeholder

* remove unused css

* remove focus for now
* cursor change to grabbing when active in canvas

* make change image button support keyboard
* add validationmessages for imageupload size and types

* clear validationmessages on cancel
* add language texts and use them throughout the component

* change wording slightly and remove unused text

* make file-endings the same
@lassopicasso
Copy link
Contributor

/publish

Copy link
Contributor

github-actions bot commented Oct 7, 2025

PR release:

  • <link rel="stylesheet" type="text/css" href="https://altinncdn.no/toolkits/altinn-app-frontend/4.22.0-pr.2628.image-upload-component.057189ad/altinn-app-frontend.css">
  • <script src="https://altinncdn.no/toolkits/altinn-app-frontend/4.22.0-pr.2628.image-upload-component.057189ad/altinn-app-frontend.js"></script>

⚙️ Building...
✅ Done!

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (4)
src/layout/ImageUpload/imageUploadUtils.ts (4)

34-45: Use natural image dimensions; handle zero to avoid Infinity/NaN.

This issue was flagged in a previous review: HTMLImageElement.width/height returns CSS pixels, but pixel-accurate canvas calculations require naturalWidth/naturalHeight. Additionally, guard against zero dimensions to prevent Infinity/NaN in clamp calculations.

Refer to the previous review comment for the complete fix.


54-61: Use natural image dimensions; handle zero to avoid Infinity/NaN.

This function has the same issue flagged in the previous review: use naturalWidth/naturalHeight instead of width/height for accurate canvas calculations, and guard against zero dimensions.

Refer to the previous review comment for the complete fix.


98-115: Guard zoom math against invalid ranges; clamp and avoid NaN/∞.

This issue was flagged in a previous review: Math.log with non-positive minZoom/maxZoom/value yields -Infinity/NaN. The code needs input validation and output clamping to keep the slider domain stable.

Refer to the previous review comment for the complete fix.


118-119: Use natural image dimensions; handle zero to avoid Infinity/NaN.

This function has the same issue flagged in the previous review: use naturalWidth/naturalHeight instead of width/height, and guard against zero dimensions to prevent returning Infinity.

Refer to the previous review comment for the complete fix.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d0993c6 and 057189a.

📒 Files selected for processing (9)
  • src/language/texts/en.ts (1 hunks)
  • src/language/texts/nb.ts (2 hunks)
  • src/language/texts/nn.ts (2 hunks)
  • src/layout/ImageUpload/ImageControllers.tsx (1 hunks)
  • src/layout/ImageUpload/ImageCropper.tsx (1 hunks)
  • src/layout/ImageUpload/ImageDropzone.tsx (1 hunks)
  • src/layout/ImageUpload/ImageUploadSummary2.test.tsx (1 hunks)
  • src/layout/ImageUpload/imageUploadUtils.test.tsx (1 hunks)
  • src/layout/ImageUpload/imageUploadUtils.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/language/texts/nn.ts
  • src/layout/ImageUpload/ImageUploadSummary2.test.tsx
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx}: Avoid using any and unnecessary type casts (as Type) in TypeScript; prefer precise typings and refactor existing casts/anys
For TanStack Query, use objects to manage query keys and functions, and centralize shared options via queryOptions

Files:

  • src/layout/ImageUpload/ImageCropper.tsx
  • src/layout/ImageUpload/ImageControllers.tsx
  • src/layout/ImageUpload/ImageDropzone.tsx
  • src/language/texts/nb.ts
  • src/layout/ImageUpload/imageUploadUtils.ts
  • src/layout/ImageUpload/imageUploadUtils.test.tsx
  • src/language/texts/en.ts
**/*.test.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

In tests, use renderWithProviders from src/test/renderWithProviders.tsx to supply required form layout context

Files:

  • src/layout/ImageUpload/imageUploadUtils.test.tsx
🧬 Code graph analysis (4)
src/layout/ImageUpload/ImageCropper.tsx (7)
src/layout/ImageUpload/imageUploadUtils.ts (8)
  • CropArea (8-8)
  • Position (1-1)
  • calculateMinZoom (118-119)
  • constrainToArea (34-45)
  • imagePlacement (54-61)
  • validateFile (121-144)
  • cropAreaPlacement (66-70)
  • drawCropArea (79-87)
src/layout/ImageUpload/useImageFile.tsx (1)
  • useImageFile (17-52)
src/layout/ImageUpload/ImageDropzone.tsx (1)
  • ImageDropzone (18-58)
src/app-components/Card/Card.tsx (1)
  • AppCard (20-54)
src/layout/ImageUpload/ImageCanvas.tsx (1)
  • ImageCanvas (25-161)
src/layout/ImageUpload/ImageControllers.tsx (1)
  • ImageControllers (25-155)
src/features/language/Lang.tsx (1)
  • Lang (15-23)
src/layout/ImageUpload/ImageControllers.tsx (4)
src/features/attachments/index.ts (1)
  • UploadedAttachment (20-20)
src/features/language/useLanguage.ts (1)
  • useLanguage (90-93)
src/layout/ImageUpload/imageUploadUtils.ts (3)
  • normalToLogZoom (104-107)
  • isAnimationFile (146-149)
  • logToNormalZoom (109-115)
src/features/language/Lang.tsx (1)
  • Lang (15-23)
src/layout/ImageUpload/ImageDropzone.tsx (4)
src/app-components/Dropzone/Dropzone.tsx (2)
  • IDropzoneProps (15-27)
  • Dropzone (31-99)
src/hooks/useDeviceWidths.ts (1)
  • useIsMobileOrTablet (33-35)
src/components/label/Label.tsx (1)
  • getDescriptionId (135-141)
src/features/language/Lang.tsx (1)
  • Lang (15-23)
src/layout/ImageUpload/imageUploadUtils.test.tsx (1)
src/layout/ImageUpload/imageUploadUtils.ts (10)
  • getCropArea (10-25)
  • constrainToArea (34-45)
  • imagePlacement (54-61)
  • cropAreaPlacement (66-70)
  • drawCropArea (79-87)
  • normalToLogZoom (104-107)
  • logToNormalZoom (109-115)
  • calculateMinZoom (118-119)
  • validateFile (121-144)
  • isAnimationFile (146-149)
🔇 Additional comments (6)
src/layout/ImageUpload/imageUploadUtils.ts (6)

1-25: LGTM! Clean type definitions and getCropArea logic.

The type definitions are precise, and the getCropArea function correctly enforces the circle constraint (width = height) by using the minimum dimension when the type is Circle.


66-70: LGTM! Correct centering calculation.

The function correctly centers the crop area within the canvas.


79-87: LGTM! Correct canvas drawing for both crop shapes.

The function properly handles both circle (using arc) and square (using rect) crop areas.


121-144: LGTM! File validation logic is correct.

The validation correctly checks:

  • File presence
  • Size limit (10 MB)
  • Image type verification

The constants MAX_FILE_SIZE_MB and MAX_FILE_SIZE_BYTES are currently scoped inside the function. If these need to be referenced externally (as suggested by the AI summary), consider exporting them at the module level.


146-149: LGTM! Animation detection logic is correct.

The function properly detects animation mime types with case-insensitive matching.


1-1: Note: Past review comment about getAcceptedFiles doesn't apply to this file.

One of the past review comments mentions a getAcceptedFiles function with accept string validation issues. This function is not present in imageUploadUtils.ts. The comment may refer to a different file or the function may have been moved elsewhere.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (4)
src/layout/ImageUpload/ImageCropper.tsx (2)

99-114: Clean up pending Image/FileReader to avoid leaks and stale callbacks
When replacing files or unmounting mid‑load, the previous Image can continue loading. Track and cancel previous loaders; guard onload against stale updates.

+  const loadingImageRef = useRef<HTMLImageElement | null>(null);
+  const fileReaderRef = useRef<FileReader | null>(null);
   const handleFileUpload = (file: File) => {
     const validationErrors = validateFile(file);
     setValidationErrors(validationErrors);
     if (validationErrors.length > 0) {
       return;
     }
-
-    const reader = new FileReader();
+    // Cleanup any in-flight loaders
+    if (loadingImageRef.current) {
+      loadingImageRef.current.onload = null;
+      loadingImageRef.current.src = '';
+      loadingImageRef.current = null;
+    }
+    if (fileReaderRef.current && 'readyState' in fileReaderRef.current) {
+      try { fileReaderRef.current.abort?.(); } catch {}
+    }
+    const reader = new FileReader();
+    fileReaderRef.current = reader;
     reader.onload = (event) => {
       const result = event.target?.result;
 
       if (typeof result === 'string') {
         const img = new Image();
+        loadingImageRef.current = img;
         img.id = file.name;
         imageTypeRef.current = file.type;
         img.onload = () => {
-          updateImageState({ minZoom: calculateMinZoom({ img, cropArea }), img });
+          if (loadingImageRef.current === img) {
+            updateImageState({ minZoom: calculateMinZoom({ img, cropArea }), img });
+            loadingImageRef.current = null;
+          }
         };
         img.src = result;
       }
     };
     reader.readAsDataURL(file);
   };
+  React.useEffect(() => () => {
+    if (loadingImageRef.current) {
+      loadingImageRef.current.onload = null;
+      loadingImageRef.current.src = '';
+    }
+    try { fileReaderRef.current?.abort?.(); } catch {}
+  }, []);

136-144: Handle canvas.toBlob failure and report to user

If toBlob returns null, provide feedback; also wrap File creation to catch exceptions.

-    cropCanvas.toBlob((blob) => {
-      if (!blob) {
-        return;
-      }
-      const fileName = img.id;
-      const imageFile = new File([blob], fileName, { type: 'image/png' });
-      saveImage(imageFile);
-      setValidationErrors(null);
-    }, 'image/png');
+    cropCanvas.toBlob((blob) => {
+      if (!blob) {
+        console.error('Failed to create image blob');
+        setValidationErrors(['image_upload_component.error_generate_image_failed']);
+        return;
+      }
+      try {
+        const fileName = img.id;
+        const imageFile = new File([blob], fileName, { type: 'image/png' });
+        saveImage(imageFile);
+        setValidationErrors(null);
+      } catch (e) {
+        console.error('Failed to save image', e);
+        setValidationErrors(['image_upload_component.error_generate_image_failed']);
+      }
+    }, 'image/png');
src/layout/ImageUpload/ImageControllers.tsx (2)

45-53: Fix zoom slider normalization (broken zoom and thumb position)

Utils expect 0–1, slider is 0–100. Divide input by 100; multiply value by 100.

   const handleSliderZoom = (e: React.ChangeEvent<HTMLInputElement>) => {
     const logarithmicZoomValue = normalToLogZoom({
-      value: Number.parseFloat(e.target.value),
+      value: Number.parseFloat(e.target.value) / 100,
       minZoom,
       maxZoom,
     });
@@
           <input
             id={zoomId}
             type='range'
             min='0'
             max='100'
             step='0.5'
-            value={logToNormalZoom({ value: zoom, minZoom, maxZoom })}
+            value={logToNormalZoom({ value: zoom, minZoom, maxZoom }) * 100}
             onChange={handleSliderZoom}
             className={classes.zoomSlider}
           />

Also applies to: 89-96


99-109: Respect readOnly and add accessible name for Reset; simplify “Change” to a real button

  • Disable Save/Reset/Change/Cancel when readOnly.
  • Provide aria-label for the icon-only Reset button.
  • Avoid asChild with a Label; use a real Button that clicks the input ref so disabled works.
    [accessibility]
-          <Button
-            onClick={onReset}
-            variant='tertiary'
-            icon={true}
-          >
-            <ArrowUndoIcon
-              title={langAsString('image_upload_component.reset')}
-              className={classes.resetButton}
-            />
-          </Button>
+          <Button
+            onClick={onReset}
+            variant='tertiary'
+            icon
+            aria-label={langAsString('image_upload_component.button_reset')}
+            disabled={readOnly}
+          >
+            <ArrowUndoIcon className={classes.resetButton} />
+          </Button>
@@
         <Button
           onClick={onSave}
           data-size='sm'
           data-color='accent'
+          disabled={readOnly}
         >
           <Lang id='image_upload_component.button_save' />
         </Button>
@@
-        <Button
-          asChild
-          data-size='sm'
-          variant='secondary'
-          data-color='accent'
-          tabIndex={0}
-          onKeyDown={(e) => {
-            if (e.key === 'Enter' || e.key === ' ') {
-              e.preventDefault();
-              fileInputRef?.current?.click();
-            }
-          }}
-        >
-          <Label htmlFor={inputId}>
-            <UploadIcon />
-            <Lang id='image_upload_component.button_change' />
-          </Label>
-        </Button>
+        <Button
+          data-size='sm'
+          variant='secondary'
+          data-color='accent'
+          onClick={() => fileInputRef.current?.click()}
+          disabled={readOnly}
+        >
+          <UploadIcon />
+          <Lang id='image_upload_component.button_change' />
+        </Button>
         <Button
           data-size='sm'
           variant='tertiary'
           onClick={onCancel}
           data-color='accent'
+          disabled={readOnly}
         >
           <Lang id='general.cancel' />
         </Button>

Also applies to: 112-118, 128-145, 146-153

🧹 Nitpick comments (3)
src/layout/ImageUpload/ImageUploadComponent.tsx (2)

33-36: Remove unnecessary type cast on getCropArea input

The object passed to getCropArea matches its expected params; the as CropArea cast is unnecessary and misleads types. Drop the cast.
[typescript] [As per coding guidelines]

-          cropArea={getCropArea({ width: cropWidth, height: cropHeight, type: cropShape } as CropArea)}
+          cropArea={getCropArea({ width: cropWidth, height: cropHeight, type: cropShape })}

21-36: Verify that Label htmlFor matches the interactive control id

Label uses htmlFor={id}, while ImageDropzone sets id={baseComponentId}. In repeating groups, these can differ (indexed id). Ensure the same id is used for both to keep label association and a11y intact (pass the actual control id down to ImageCropper/ImageDropzone if needed).

If misaligned, prefer passing the indexed id from useLabel/useIndexedId to Dropzone.

src/layout/ImageUpload/ImageControllers.tsx (1)

123-125: Align accepted file types with policy (extensions/types)

Restrict accept to the allowed types from the PR: png, jpg/jpeg, heic, webp.

-          accept='image/*'
+          accept='.png,.jpg,.jpeg,.heic,.webp,image/png,image/jpeg,image/heic,image/webp'

Please confirm HEIC support in your target browsers/environments.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 057189a and 5f0dbcf.

📒 Files selected for processing (4)
  • src/layout/ImageUpload/ImageControllers.tsx (1 hunks)
  • src/layout/ImageUpload/ImageCropper.tsx (1 hunks)
  • src/layout/ImageUpload/ImageUploadComponent.test.tsx (1 hunks)
  • src/layout/ImageUpload/ImageUploadComponent.tsx (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/layout/ImageUpload/ImageUploadComponent.test.tsx
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx}: Avoid using any and unnecessary type casts (as Type) in TypeScript; prefer precise typings and refactor existing casts/anys
For TanStack Query, use objects to manage query keys and functions, and centralize shared options via queryOptions

Files:

  • src/layout/ImageUpload/ImageCropper.tsx
  • src/layout/ImageUpload/ImageControllers.tsx
  • src/layout/ImageUpload/ImageUploadComponent.tsx
🧬 Code graph analysis (3)
src/layout/ImageUpload/ImageCropper.tsx (7)
src/layout/ImageUpload/imageUploadUtils.ts (8)
  • CropArea (8-8)
  • Position (1-1)
  • calculateMinZoom (118-119)
  • constrainToArea (34-45)
  • imagePlacement (54-61)
  • validateFile (121-144)
  • cropAreaPlacement (66-70)
  • drawCropArea (79-87)
src/layout/ImageUpload/useImageFile.tsx (1)
  • useImageFile (17-52)
src/layout/ImageUpload/ImageDropzone.tsx (1)
  • ImageDropzone (18-58)
src/app-components/Card/Card.tsx (1)
  • AppCard (20-54)
src/layout/ImageUpload/ImageCanvas.tsx (1)
  • ImageCanvas (25-161)
src/layout/ImageUpload/ImageControllers.tsx (1)
  • ImageControllers (26-157)
src/features/language/Lang.tsx (1)
  • Lang (15-23)
src/layout/ImageUpload/ImageControllers.tsx (4)
src/features/attachments/index.ts (1)
  • UploadedAttachment (20-20)
src/features/language/useLanguage.ts (1)
  • useLanguage (90-93)
src/layout/ImageUpload/imageUploadUtils.ts (3)
  • normalToLogZoom (104-107)
  • isAnimationFile (146-149)
  • logToNormalZoom (109-115)
src/features/language/Lang.tsx (1)
  • Lang (15-23)
src/layout/ImageUpload/ImageUploadComponent.tsx (6)
src/layout/index.ts (1)
  • PropsFromGenericComponent (29-33)
src/utils/layout/useNodeItem.ts (1)
  • useItemWhenType (15-33)
src/utils/layout/useLabel.tsx (1)
  • useLabel (13-72)
src/layout/ComponentStructureWrapper.tsx (1)
  • ComponentStructureWrapper (20-48)
src/layout/ImageUpload/ImageCropper.tsx (1)
  • ImageCropper (29-213)
src/layout/ImageUpload/imageUploadUtils.ts (2)
  • getCropArea (10-25)
  • CropArea (8-8)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Type-checks, eslint, unit tests and SonarCloud
  • GitHub Check: Install
  • GitHub Check: Analyze (javascript)

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/layout/ImageUpload/ImageUploadComponent.tsx (1)

35-35: Consider simplifying boolean coercion.

The !!readOnly double negation is defensive but unnecessary if readOnly is already typed as boolean | undefined and ImageCropper accepts readOnly?: boolean. Simply passing readOnly would be cleaner.

Apply this diff if the types align:

-          readOnly={!!readOnly}
+          readOnly={readOnly}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5f0dbcf and 50f6b68.

📒 Files selected for processing (2)
  • src/layout/FileUpload/FileUploadComponent.test.tsx (1 hunks)
  • src/layout/ImageUpload/ImageUploadComponent.tsx (1 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx}: Avoid using any and unnecessary type casts (as Type) in TypeScript; prefer precise typings and refactor existing casts/anys
For TanStack Query, use objects to manage query keys and functions, and centralize shared options via queryOptions

Files:

  • src/layout/FileUpload/FileUploadComponent.test.tsx
  • src/layout/ImageUpload/ImageUploadComponent.tsx
**/*.test.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

In tests, use renderWithProviders from src/test/renderWithProviders.tsx to supply required form layout context

Files:

  • src/layout/FileUpload/FileUploadComponent.test.tsx
🧬 Code graph analysis (1)
src/layout/ImageUpload/ImageUploadComponent.tsx (6)
src/layout/index.ts (1)
  • PropsFromGenericComponent (29-33)
src/utils/layout/useNodeItem.ts (1)
  • useItemWhenType (15-33)
src/utils/layout/useLabel.tsx (1)
  • useLabel (13-72)
src/layout/ComponentStructureWrapper.tsx (1)
  • ComponentStructureWrapper (20-48)
src/layout/ImageUpload/ImageCropper.tsx (1)
  • ImageCropper (29-213)
src/layout/ImageUpload/imageUploadUtils.ts (1)
  • getCropArea (10-25)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Install
  • GitHub Check: Type-checks, eslint, unit tests and SonarCloud
🔇 Additional comments (2)
src/layout/FileUpload/FileUploadComponent.test.tsx (1)

476-476: LGTM! Translation refinement.

The test expectation has been correctly updated to reflect the refined Norwegian text from "let etter fil" to "finn fil". This aligns with the updated UI text in the Dropzone component.

src/layout/ImageUpload/ImageUploadComponent.tsx (1)

12-16: LGTM! readOnly now propagated correctly.

The component now correctly destructures readOnly from useItemWhenType and passes it through to ImageCropper (line 35), addressing the previous review concern.

@lassopicasso lassopicasso added the squad/utforming Issues that belongs to the named squad. label Oct 7, 2025
@framitdavid framitdavid self-assigned this Oct 9, 2025
Copy link
Contributor

@olemartinorg olemartinorg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fantastic stuff!! 🚀 This is a solid delivery. I have some comments, but nothing major.

.addProperty(
new CG.prop(
'cropShape',
new CG.enum('square', 'circle')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 'square' setting can also be a rectangle, so that might be a better naming. It's only a square if you set cropWidth and cropHeight to the same values, but if you specify different values you'll make a rectangle, not a square.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, will change it.

Comment on lines +31 to +42
.addProperty(
new CG.prop(
'cropWidth',
new CG.num().optional({ default: 250 }).setTitle('Width').setDescription('Optional width of the cropping area'),
),
)
.addProperty(
new CG.prop(
'cropHeight',
new CG.num().optional({ default: 250 }).setTitle('Height').setDescription('Optional height of the cropping area'),
),
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You might want to add the detail that for 'circle' only the smallest of these two actually have an effect. Thus, maybe 'circle' is not as great as a default value - since these two options have weird effects? You could potentially also add a union type that removes the gotchas in this config, such as only allowing objects:

"crop": {
  "shape": "circle",
  "diameter": 250
}
"crop": {
  "shape": "rectangle",
  "width": 250,
  "height": 250
}

Example of how to do this in config.ts:

Config.addProperty(
  new CG.prop(
    'crop',
    new CG.union(
      new CG.obj(
        new CG.prop('shape', new CG.const('circle').setTitle('Shape').setDescription('Circular cropping area')),
        new CG.prop(
          'diameter',
          new CG.num().optional({ default: 250 }).setTitle('Diameter').setDescription('Diameter of the circle'),
        ),
      ).exportAs('CropConfigCircle'),
      new CG.obj(
        new CG.prop('shape', new CG.const('rectangle').setTitle('Shape').setDescription('Rectangular cropping area')),
        new CG.prop(
          'width',
          new CG.num().optional({ default: 250 }).setTitle('Width').setDescription('Width of the rectangle'),
        ),
        new CG.prop(
          'height',
          new CG.num().optional({ default: 250 }).setTitle('Height').setDescription('Height of the rectangle'),
        ),
      ).exportAs('CropConfigRect'),
    )
      .setUnionType('discriminated')
      .optional({ default: { shape: 'circle', diameter: 250 } })
      .exportAs('CropConfig'),
  ),
)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice suggestion! 👍 I will take a look at it, and see how we can display this in studio designer as well. For now we have a selector for shape and 2 input fields for width and height.

const handleWheel = useCallback(
(e: WheelEvent) => {
e.preventDefault();
onZoomChange(zoom - e.deltaY * 0.001);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I found this to be less useful than I hoped. The first scroll 'click' zooms in waay too much, but later clicks zoom in less and less. I didn't need all that finesse when looking at huge pixels, but I wanted more finesse at first.. 😅

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, you mean it should be less zoom sensitive in the first part of the zoom scale when working with huge pixels?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

haha, I see what you mean 😆 Here I scroll:

zoom.mp4

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, exactly!

const imageFile = new File([blob], fileName, { type: 'image/png' });
saveImage(imageFile);
setValidationErrors(null);
}, 'image/png');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, nice! Looks very easy to add an option to upload as jpeg as well (seems supported in all major browsers). I see lots of apps have added limitations in the allowedContentTypes property for the data element type in applicationmetadata, so maybe we should check that and upload as image/jpeg if image/png is not allowed by the app. Aaaand throw an error early if none of the supported image types are allowed, I guess.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, will look into that 🙂

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this utility.
If allowedContentTypes is not set - use png.
If allowedContentTypes is set - check if png is allowed, if not, check if jpeg is allowed.
If neither png or jpeg is allowed, check if there is other image types that is allowed.
If none above, throw an error when the component is rendered.

export const getAllowedImageType = ({ baseComponentId, dataTypes }: AllowedImageTypeParams) => {
  const dataType = dataTypes.find((datatype) => datatype.id === baseComponentId);
  const defaultTypes = ['image/png', 'image/jpeg'];

  const allowed = dataType?.allowedContentTypes ?? [];
  if (allowed.length === 0) {
    return defaultTypes[0];
  }

  const preferred = defaultTypes.find((type) => allowed.includes(type));
  if (preferred) {
    return preferred;
  }

  const firstImageAllowed = allowed.find((type) => type.startsWith('image/'));
  if (firstImageAllowed) {
    return firstImageAllowed;
  }

  throw new Error(`allowedContentTypes is configured for dataType '${baseComponentId}', but contains no image types.`);
};

Copy link
Contributor

@lassopicasso lassopicasso Oct 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had to go back one this. To only save as PNG, but have kept validation to check if allowedContentTypes is defined and if so check if image/png exists there, if it doesnt exist then an error message is thrown.
The reason for this is that JPEG doesn't support transparency well, so a circle will be wrapped with a black square.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense! 🙌

Copy link
Contributor

@framitdavid framitdavid left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid work! Few comments to be considered.

}

.previewBackground {
background-color: #f4f5f6; /* Following does not exist in v1: var(--ds-color-neutral-background-subtle); */
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we use a different color here? If this color already exists elsewhere, we should align with that and reference a shared CSS variable/design token. If it’s specific to this component only, consider introducing a v1 component-scoped token instead of hard-coding the value.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We will look for a similar token instead :)

@@ -0,0 +1,161 @@
import React, { useCallback, useEffect } from 'react';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work on this component! This isn’t a blocker, but it currently mixes concerns, Single Responsibility Principle. It handles rendering, event handling, drag-and-drop, zoom, keyboard navigation, and canvas drawing in one place. Consider splitting these out to improve readability and testability.

An alternative is to split responsibilities into separate modules. I’m not suggesting we scatter logic—maintaining cohesion matters—but we can keep everything under the component’s folder and factor it into focused files.

 ImageCanvas.tsx 
 ├── hooks/
 │   ├──hooks.something.ts
 ├── utils/
 │   ├── SomeUtiLS

The ImageCanvas could look like the example below. We’re not writing unit tests with Vitest or Jest right now, so most coverage sits in Cypress so its easy to test as is.

 export function ImageCanvas({
    imageRef,
    zoom,
    position,
    cropArea,
    baseComponentId,
    onPositionChange,
    onZoomChange,
    canvasRef,
  }: ImageCanvasProps) {
    const { storedImage, imageUrl } = useImageFile(baseComponentId);

    // extract canvas and zoom into custom hooks. 
    useCanvasDraw({ canvasRef, imageRef, zoom, position, cropArea });
    useZoomInteraction(canvasRef, zoom, onZoomChange);
    
    const { handlePointerDown } = useDragInteraction(canvasRef, position, onPositionChange);
    
    
    const { handleKeyDown } = useKeyboardNavigation(position, onPositionChange);
    
    // Ref. previous comment, the code below seperated the preview to explicit component.
    if (storedImage) {
      return <UploadedImagePreview storedImage={storedImage} imageUrl={imageUrl} />;
    }

    return (
      <canvas
        onPointerDown={handlePointerDown}
        onKeyDown={handleKeyDown}
        tabIndex={0}
        ref={canvasRef}
        height={CANVAS_HEIGHT}
        width={CANVAS_WIDTH}
        className={classes.canvas}
        aria-label='Image cropping area' // <-- Ref. previous comment, this should support multiple langues.
      />
    );
  }

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very good feedback, will do that.

Comment on lines +54 to +87
export const imagePlacement = ({ canvas, img, zoom, position }: ImagePlacementParams) => {
const scaledWidth = img.width * zoom;
const scaledHeight = img.height * zoom;
const imgX = (canvas.width - scaledWidth) / 2 + position.x;
const imgY = (canvas.height - scaledHeight) / 2 + position.y;

return { imgX, imgY, scaledWidth, scaledHeight };
};

type CropAreaPlacementParams = { canvas: HTMLCanvasElement; cropArea: CropArea };
type CropAreaPlacement = { cropAreaX: number; cropAreaY: number };

export const cropAreaPlacement = ({ canvas, cropArea }: CropAreaPlacementParams): CropAreaPlacement => {
const cropAreaX = (canvas.width - cropArea.width) / 2;
const cropAreaY = (canvas.height - cropArea.height) / 2;
return { cropAreaX, cropAreaY };
};

interface DrawCropAreaParams {
ctx: CanvasRenderingContext2D;
cropArea: CropArea;
x?: number;
y?: number;
}

export function drawCropArea({ ctx, x = 0, y = 0, cropArea }: DrawCropAreaParams) {
const { width, height, type } = cropArea;
ctx.beginPath();
if (type === CropForm.Circle) {
ctx.arc(x + width / 2, y + height / 2, width / 2, 0, Math.PI * 2);
} else {
ctx.rect(x, y, width, height);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we extract the magic numbers into named constants? I notice the value 2 appears multiple times—do these instances represent the same domain rule, or are they unrelated?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The divider of 2 is often used to find the center of the crop area. Can look into it

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (10)
src/layout/ImageUpload/ImageCropper.tsx (4)

94-116: Memory leak: Image and FileReader not cleaned up

The Image and FileReader created in handleFileUpload are not cleaned up if the component unmounts or a new file is uploaded before the previous load completes. This can cause memory leaks and stale callbacks.

Track loading resources in refs and clean them up:

+  const loadingImageRef = useRef<HTMLImageElement | null>(null);
+  const fileReaderRef = useRef<FileReader | null>(null);
+
   const handleFileUpload = (file: File) => {
     const validationErrors = validateFile(file);
     setValidationErrors(validationErrors);
     if (validationErrors.length > 0) {
       return;
     }

+    // Clean up previous loading resources
+    if (loadingImageRef.current) {
+      loadingImageRef.current.onload = null;
+      loadingImageRef.current.src = '';
+      loadingImageRef.current = null;
+    }
+    if (fileReaderRef.current) {
+      fileReaderRef.current.abort();
+      fileReaderRef.current = null;
+    }
+
     const reader = new FileReader();
+    fileReaderRef.current = reader;
     reader.onload = (event) => {
       const result = event.target?.result;

       if (typeof result === 'string') {
         const img = new Image();
+        loadingImageRef.current = img;
         img.id = file.name;
         imageTypeRef.current = file.type;
         img.onload = () => {
+          if (loadingImageRef.current === img) {
             updateImageState({ minZoom: calculateMinZoom({ img, cropArea }), img });
+            loadingImageRef.current = null;
+          }
         };
         img.src = result;
       }
     };
     reader.readAsDataURL(file);
   };
+
+  // Clean up on unmount
+  useEffect(() => {
+    return () => {
+      if (loadingImageRef.current) {
+        loadingImageRef.current.onload = null;
+        loadingImageRef.current.src = '';
+      }
+      if (fileReaderRef.current) {
+        fileReaderRef.current.abort();
+      }
+    };
+  }, []);

138-148: Handle toBlob failures with user feedback

The toBlob callback silently fails when blob is null, providing no user feedback. Consider adding error handling and notifying the user.

     cropCanvas.toBlob((blob) => {
       if (!blob) {
+        console.error('Failed to create image blob from canvas');
+        setValidationErrors(['image_upload_component.error_save_failed']);
         return;
       }

       const newFileName = getNewFileName({ fileName: img.id });
       const imageFile = new File([blob], newFileName, { type: IMAGE_TYPE });
       saveImage(imageFile);
       setValidationErrors(null);
     }, IMAGE_TYPE);

Add the error key to your language files:

'image_upload_component.error_save_failed': 'Failed to save the cropped image. Please try again.'

94-116: Clean up FileReader and Image to prevent memory leaks.

The FileReader and Image instances are not cleaned up if the component unmounts during loading or if a new file is uploaded before the previous one completes. This causes unnecessary resource consumption and stale callbacks.

Track loading state and clean up on unmount or new uploads:

+  const loadingImageRef = useRef<HTMLImageElement | null>(null);
+  const fileReaderRef = useRef<FileReader | null>(null);
+
   const handleFileUpload = (file: File) => {
     const validationErrors = validateFile(file);
     setValidationErrors(validationErrors);
     if (validationErrors.length > 0) {
       return;
     }

+    // Clean up previous loading operations
+    if (loadingImageRef.current) {
+      loadingImageRef.current.onload = null;
+      loadingImageRef.current.src = '';
+      loadingImageRef.current = null;
+    }
+    if (fileReaderRef.current) {
+      fileReaderRef.current.onload = null;
+      fileReaderRef.current = null;
+    }
+
     const reader = new FileReader();
+    fileReaderRef.current = reader;
     reader.onload = (event) => {
       const result = event.target?.result;

       if (typeof result === 'string') {
         const img = new Image();
+        loadingImageRef.current = img;
         img.id = file.name;
         imageTypeRef.current = file.type;
         img.onload = () => {
+          if (loadingImageRef.current === img) {
             updateImageState({ minZoom: calculateMinZoom({ img, cropArea }), img });
+            loadingImageRef.current = null;
+          }
         };
         img.src = result;
       }
     };
     reader.readAsDataURL(file);
   };

Add cleanup on unmount:

useEffect(() => {
  return () => {
    if (loadingImageRef.current) {
      loadingImageRef.current.onload = null;
      loadingImageRef.current.src = '';
    }
    if (fileReaderRef.current) {
      fileReaderRef.current.onload = null;
    }
  };
}, []);

138-148: Add error handling and user feedback for toBlob failures.

The toBlob callback doesn't handle failures beyond an early return. Users won't know if the crop operation failed.

Add error logging and consider user feedback:

     cropCanvas.toBlob((blob) => {
       if (!blob) {
+        console.error('Failed to create image blob during crop operation');
+        // TODO: Consider adding user-visible error feedback (toast/alert)
         return;
       }

       const newFileName = getNewFileName({ fileName: img.id });
       const imageFile = new File([blob], newFileName, { type: IMAGE_TYPE });
       saveImage(imageFile);
       setValidationErrors(null);
     }, IMAGE_TYPE);
src/layout/ImageUpload/imageUploadUtils.ts (6)

41-52: Use naturalWidth/naturalHeight for accurate pixel calculations

Lines 42-43 use image.width and image.height, which are CSS pixels and may not reflect the actual image dimensions. For pixel-accurate canvas calculations, use naturalWidth and naturalHeight. Also guard against zero dimensions to prevent NaN or Infinity.

Apply this diff:

 export function constrainToArea({ image, zoom, position, cropArea }: ConstrainToAreaParams): Position {
-  const scaledWidth = image.width * zoom;
-  const scaledHeight = image.height * zoom;
+  const iw = image.naturalWidth || image.width || 1;
+  const ih = image.naturalHeight || image.height || 1;
+  const scaledWidth = iw * zoom;
+  const scaledHeight = ih * zoom;

   const clampX = scaledWidth > cropArea.width ? (scaledWidth - cropArea.width) / 2 : 0;
   const clampY = scaledHeight > cropArea.height ? (scaledHeight - cropArea.height) / 2 : 0;

   const newX = Math.max(-clampX, Math.min(position.x, clampX));
   const newY = Math.max(-clampY, Math.min(position.y, clampY));

   return { x: newX, y: newY };
 }

61-68: Use naturalWidth/naturalHeight for accurate pixel calculations

Lines 62-63 use img.width and img.height. For pixel-accurate canvas calculations, use naturalWidth and naturalHeight.

Apply this diff:

 export const imagePlacement = ({ canvas, img, zoom, position }: ImagePlacementParams) => {
-  const scaledWidth = img.width * zoom;
-  const scaledHeight = img.height * zoom;
+  const iw = img.naturalWidth || img.width || 1;
+  const ih = img.naturalHeight || img.height || 1;
+  const scaledWidth = iw * zoom;
+  const scaledHeight = ih * zoom;
   const imgX = (canvas.width - scaledWidth) / 2 + position.x;
   const imgY = (canvas.height - scaledHeight) / 2 + position.y;

   return { imgX, imgY, scaledWidth, scaledHeight };
 };

105-122: Guard zoom calculations against invalid values

getLogValues doesn't validate that minZoom and maxZoom are positive. Math.log of non-positive values returns -Infinity or NaN, breaking the zoom calculations. Additionally, logToNormalZoom should clamp its result to [0, 100] to keep the slider stable.

Apply this diff:

 function getLogValues({ minZoom, maxZoom }: ZoomParams): { logScale: number; logMin: number } {
+  if (minZoom <= 0 || maxZoom <= 0) {
+    throw new Error('minZoom and maxZoom must be positive');
+  }
+  if (minZoom >= maxZoom) {
+    throw new Error('minZoom must be less than maxZoom');
+  }
   const logMin = Math.log(minZoom);
   const logMax = Math.log(maxZoom);
   return { logScale: (logMax - logMin) / 100, logMin };
 }

 export function normalToLogZoom({ value, minZoom, maxZoom }: CalculateZoomParams): number {
   const { logScale, logMin } = getLogValues({ minZoom, maxZoom });
-  return Math.exp(logMin + logScale * value);
+  const result = Math.exp(logMin + logScale * value);
+  return Math.max(minZoom, Math.min(result, maxZoom));
 }

 export function logToNormalZoom({ value, minZoom, maxZoom }: CalculateZoomParams): number {
   const { logScale, logMin } = getLogValues({ minZoom, maxZoom });
   if (logScale === 0) {
     return 0;
   }
-  return (Math.log(value) - logMin) / logScale;
+  const safeValue = Math.max(value, Number.EPSILON);
+  const normalized = (Math.log(safeValue) - logMin) / logScale;
+  return Math.max(0, Math.min(normalized, 100));
 }

125-126: Use naturalWidth/naturalHeight and guard against zero dimensions

calculateMinZoom uses img.width and img.height instead of natural dimensions. It also doesn't guard against zero dimensions, which would produce Infinity.

Apply this diff:

-export const calculateMinZoom = ({ img, cropArea }: CalculateMinZoomParams) =>
-  Math.max(cropArea.width / img.width, cropArea.height / img.height);
+export const calculateMinZoom = ({ img, cropArea }: CalculateMinZoomParams) => {
+  const iw = img.naturalWidth || img.width || 1;
+  const ih = img.naturalHeight || img.height || 1;
+  return Math.max(cropArea.width / iw, cropArea.height / ih);
+};

41-52: Use naturalWidth/naturalHeight for pixel-accurate calculations.

Lines 42-43, 62-63, and 126 use image.width/image.height instead of image.naturalWidth/image.naturalHeight. The width/height properties reflect CSS sizing, which can differ from the actual image dimensions, leading to incorrect crop calculations.

Apply this diff to use natural dimensions with zero guards:

 export function constrainToArea({ image, zoom, position, cropArea }: ConstrainToAreaParams): Position {
-  const scaledWidth = image.width * zoom;
-  const scaledHeight = image.height * zoom;
+  const iw = Math.max(1, image.naturalWidth || image.width);
+  const ih = Math.max(1, image.naturalHeight || image.height);
+  const scaledWidth = iw * zoom;
+  const scaledHeight = ih * zoom;

   const clampX = scaledWidth > cropArea.width ? (scaledWidth - cropArea.width) / 2 : 0;
   const clampY = scaledHeight > cropArea.height ? (scaledHeight - cropArea.height) / 2 : 0;

   const newX = Math.max(-clampX, Math.min(position.x, clampX));
   const newY = Math.max(-clampY, Math.min(position.y, clampY));

   return { x: newX, y: newY };
 }
 export const imagePlacement = ({ canvas, img, zoom, position }: ImagePlacementParams) => {
-  const scaledWidth = img.width * zoom;
-  const scaledHeight = img.height * zoom;
+  const iw = Math.max(1, img.naturalWidth || img.width);
+  const ih = Math.max(1, img.naturalHeight || img.height);
+  const scaledWidth = iw * zoom;
+  const scaledHeight = ih * zoom;
   const imgX = (canvas.width - scaledWidth) / 2 + position.x;
   const imgY = (canvas.height - scaledHeight) / 2 + position.y;

   return { imgX, imgY, scaledWidth, scaledHeight };
 };
-export const calculateMinZoom = ({ img, cropArea }: CalculateMinZoomParams) =>
-  Math.max(cropArea.width / img.width, cropArea.height / img.height);
+export const calculateMinZoom = ({ img, cropArea }: CalculateMinZoomParams) => {
+  const iw = Math.max(1, img.naturalWidth || img.width);
+  const ih = Math.max(1, img.naturalHeight || img.height);
+  return Math.max(cropArea.width / iw, cropArea.height / ih);
+};

Also applies to: 61-68, 125-126


105-122: Guard zoom math against invalid inputs and NaN/Infinity.

Math.log with non-positive minZoom, maxZoom, or value produces -Infinity/NaN. The normalized output should also be clamped to [0, 100] to keep the slider stable.

Apply these guards:

 function getLogValues({ minZoom, maxZoom }: ZoomParams): { logScale: number; logMin: number } {
+  if (minZoom <= 0 || maxZoom <= 0) {
+    throw new Error('minZoom and maxZoom must be positive');
+  }
+  if (minZoom === maxZoom) {
+    return { logScale: 0, logMin: Math.log(minZoom) };
+  }
   const logMin = Math.log(minZoom);
   const logMax = Math.log(maxZoom);
   return { logScale: (logMax - logMin) / 100, logMin };
 }
 export function normalToLogZoom({ value, minZoom, maxZoom }: CalculateZoomParams): number {
   const { logScale, logMin } = getLogValues({ minZoom, maxZoom });
-  return Math.exp(logMin + logScale * value);
+  const result = Math.exp(logMin + logScale * value);
+  return Math.min(maxZoom, Math.max(minZoom, result));
 }
 export function logToNormalZoom({ value, minZoom, maxZoom }: CalculateZoomParams): number {
   const { logScale, logMin } = getLogValues({ minZoom, maxZoom });
   if (logScale === 0) {
-    return 0;
+    return 50; // Return middle of slider when min equals max
   }
-  return (Math.log(value) - logMin) / logScale;
+  const safeValue = Math.max(Number.EPSILON, value);
+  const normalized = (Math.log(safeValue) - logMin) / logScale;
+  return Math.min(100, Math.max(0, normalized));
 }
🧹 Nitpick comments (1)
src/layout/ImageUpload/imageUploadUtils.ts (1)

45-46: Consider extracting centering divisor as a named constant.

The value 2 appears multiple times (lines 45-46, 64-65, 74-75, 90) for centering and radius calculations. While the intent is clear in context, extracting it could improve readability per the earlier review feedback.

Based on learnings

Example:

const HALF = 2;

// Then use HALF instead of literal 2 in division operations
const clampX = scaledWidth > cropArea.width ? (scaledWidth - cropArea.width) / HALF : 0;
const imgX = (canvas.width - scaledWidth) / HALF + position.x;
const cropAreaX = (canvas.width - cropArea.width) / HALF;
ctx.arc(x + width / HALF, y + height / HALF, width / HALF, 0, Math.PI * 2);

Note: This is a minor readability enhancement and may be considered pedantic.

Also applies to: 64-65, 74-75, 90-90

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 50f6b68 and 8812b14.

📒 Files selected for processing (7)
  • src/layout/FileUpload/utils/useFileUploaderDataBindingsValidation.ts (1 hunks)
  • src/layout/ImageUpload/ImageCropper.tsx (1 hunks)
  • src/layout/ImageUpload/ImageUploadComponent.tsx (1 hunks)
  • src/layout/ImageUpload/config.ts (1 hunks)
  • src/layout/ImageUpload/imageUploadUtils.test.tsx (1 hunks)
  • src/layout/ImageUpload/imageUploadUtils.ts (1 hunks)
  • src/layout/ImageUpload/index.tsx (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/layout/ImageUpload/config.ts
  • src/layout/ImageUpload/index.tsx
  • src/layout/ImageUpload/ImageUploadComponent.tsx
  • src/layout/ImageUpload/imageUploadUtils.test.tsx
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx}: Avoid using any and unnecessary type casts (as Type) in TypeScript; prefer precise typings and refactor existing casts/anys
For TanStack Query, use objects to manage query keys and functions, and centralize shared options via queryOptions

Files:

  • src/layout/FileUpload/utils/useFileUploaderDataBindingsValidation.ts
  • src/layout/ImageUpload/ImageCropper.tsx
  • src/layout/ImageUpload/imageUploadUtils.ts
🧬 Code graph analysis (2)
src/layout/ImageUpload/ImageCropper.tsx (6)
src/layout/ImageUpload/imageUploadUtils.ts (10)
  • CropArea (15-15)
  • Position (6-6)
  • calculateMinZoom (125-126)
  • constrainToArea (41-52)
  • imagePlacement (61-68)
  • validateFile (128-151)
  • cropAreaPlacement (73-77)
  • drawCropArea (86-94)
  • getNewFileName (170-173)
  • IMAGE_TYPE (4-4)
src/layout/ImageUpload/useImageFile.tsx (1)
  • useImageFile (17-52)
src/layout/ImageUpload/ImageDropzone.tsx (1)
  • ImageDropzone (18-58)
src/layout/ImageUpload/ImageCanvas.tsx (1)
  • ImageCanvas (25-161)
src/layout/ImageUpload/ImageControllers.tsx (1)
  • ImageControllers (26-157)
src/features/language/Lang.tsx (1)
  • Lang (15-23)
src/layout/ImageUpload/imageUploadUtils.ts (1)
src/types/shared.ts (1)
  • IDataType (56-68)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Analyze (javascript)
  • GitHub Check: Type-checks, eslint, unit tests and SonarCloud
  • GitHub Check: Install
🔇 Additional comments (2)
src/layout/FileUpload/utils/useFileUploaderDataBindingsValidation.ts (1)

10-10: LGTM! Generic constraint correctly extended for ImageUpload support.

Add unit tests covering useFileUploaderDataBindingsValidation with ImageUpload bindings (simpleBinding/list) to ensure the validation paths function as expected.

src/layout/ImageUpload/imageUploadUtils.ts (1)

1-173: LGTM: Well-structured utility module.

The module provides a clean, well-organized API for image cropping calculations. The comment on Line 3 explaining the PNG choice is helpful. Type definitions are clear and the separation of concerns is good.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (3)
src/layout/ImageUpload/ImageControllers.tsx (2)

106-115: Improve reset button accessibility with aria-label.

The icon-only reset button uses a title attribute on the icon (line 112), which provides a tooltip but may not be reliably announced by all screen readers. Adding an aria-label to the Button ensures consistent accessibility.

Apply this diff to improve accessibility:

           <Button
             onClick={onReset}
             variant='tertiary'
             icon={true}
+            aria-label={langAsString('image_upload_component.reset')}
           >
-            <ArrowUndoIcon
-              title={langAsString('image_upload_component.reset')}
-              className={classes.resetButton}
-            />
+            <ArrowUndoIcon className={classes.resetButton} />
           </Button>

45-53: Critical zoom bug: slider range (0–100) doesn't match utility expectations (0–1).

The slider uses a 0–100 range (lines 96-105), but normalToLogZoom expects a normalized 0–1 value. Similarly, logToNormalZoom returns a 0–1 value that must be multiplied by 100 for the slider. This mismatch breaks zoom functionality.

Apply this diff to fix the zoom normalization:

   const handleSliderZoom = (e: React.ChangeEvent<HTMLInputElement>) => {
     const logarithmicZoomValue = normalToLogZoom({
-      value: Number.parseFloat(e.target.value),
+      value: Number.parseFloat(e.target.value) / 100,
       minZoom,
       maxZoom,
     });

     updateZoom(logarithmicZoomValue);
   };

And update the slider value prop:

           <input
             id={zoomId}
             type='range'
             min='0'
             max='100'
             step='0.5'
-            value={logToNormalZoom({ value: zoom, minZoom, maxZoom })}
+            value={logToNormalZoom({ value: zoom, minZoom, maxZoom }) * 100}
             onChange={handleSliderZoom}
             className={classes.zoomSlider}
           />
src/layout/ImageUpload/ImageCanvas.tsx (1)

69-71: Redraw when the image finishes loading

If the image isn’t complete at first render, the canvas never updates. Add an onload listener to trigger draw() once the image loads.

   useEffect(() => {
     draw();
   }, [draw]);
+
+  // Redraw once the image finishes loading
+  useEffect(() => {
+    const img = imageRef.current;
+    if (!img) return;
+    const onLoad = () => draw();
+    if (!img.complete) {
+      img.addEventListener('load', onLoad, { once: true });
+    }
+    return () => img.removeEventListener('load', onLoad);
+  }, [imageRef, draw]);
🧹 Nitpick comments (1)
src/layout/ImageUpload/ImageCanvas.tsx (1)

115-128: Clamp keyboard panning to prevent exposing empty areas

Arrow-key movement should respect the same constraints as drag.

   const handleKeyDown = (e: React.KeyboardEvent<HTMLCanvasElement>) => {
     const moveAmount = 10;
-    const keyMap: Record<string, () => void> = {
-      ArrowUp: () => onPositionChange({ ...position, y: position.y - moveAmount }),
-      ArrowDown: () => onPositionChange({ ...position, y: position.y + moveAmount }),
-      ArrowLeft: () => onPositionChange({ ...position, x: position.x - moveAmount }),
-      ArrowRight: () => onPositionChange({ ...position, x: position.x + moveAmount }),
-    };
+    const move = (dx: number, dy: number) => {
+      const next = { x: position.x + dx, y: position.y + dy };
+      const img = imageRef.current;
+      const c = canvasRef.current;
+      onPositionChange(img && c ? constrainToArea({ image: img, zoom, position: next, cropArea, canvas: c }) : next);
+    };
+    const keyMap: Record<string, () => void> = {
+      ArrowUp: () => move(0, -moveAmount),
+      ArrowDown: () => move(0, moveAmount),
+      ArrowLeft: () => move(-moveAmount, 0),
+      ArrowRight: () => move(moveAmount, 0),
+    };
 
     if (keyMap[e.key]) {
       e.preventDefault();
       keyMap[e.key]();
     }
   };
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8812b14 and 6f05f4f.

📒 Files selected for processing (8)
  • src/language/texts/en.ts (1 hunks)
  • src/language/texts/nb.ts (2 hunks)
  • src/language/texts/nn.ts (2 hunks)
  • src/layout/ImageUpload/ImageCanvas.tsx (1 hunks)
  • src/layout/ImageUpload/ImageControllers.module.css (1 hunks)
  • src/layout/ImageUpload/ImageControllers.tsx (1 hunks)
  • src/layout/ImageUpload/ImageDropzone.module.css (1 hunks)
  • src/layout/ImageUpload/ImageDropzone.tsx (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (5)
  • src/layout/ImageUpload/ImageControllers.module.css
  • src/language/texts/nb.ts
  • src/language/texts/en.ts
  • src/layout/ImageUpload/ImageDropzone.module.css
  • src/layout/ImageUpload/ImageDropzone.tsx
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx}: Avoid using any and unnecessary type casts (as Type) in TypeScript; prefer precise typings and refactor existing casts/anys
For TanStack Query, use objects to manage query keys and functions, and centralize shared options via queryOptions

Files:

  • src/layout/ImageUpload/ImageControllers.tsx
  • src/language/texts/nn.ts
  • src/layout/ImageUpload/ImageCanvas.tsx
🧬 Code graph analysis (2)
src/layout/ImageUpload/ImageControllers.tsx (4)
src/features/attachments/index.ts (1)
  • UploadedAttachment (20-20)
src/features/language/useLanguage.ts (1)
  • useLanguage (90-93)
src/layout/ImageUpload/imageUploadUtils.ts (3)
  • normalToLogZoom (111-114)
  • isAnimationFile (153-156)
  • logToNormalZoom (116-122)
src/features/language/Lang.tsx (1)
  • Lang (15-23)
src/layout/ImageUpload/ImageCanvas.tsx (3)
src/layout/ImageUpload/imageUploadUtils.ts (5)
  • Position (6-6)
  • CropArea (15-15)
  • imagePlacement (61-68)
  • cropAreaPlacement (73-77)
  • drawCropArea (86-94)
src/layout/ImageUpload/useImageFile.tsx (1)
  • useImageFile (17-52)
src/features/language/useLanguage.ts (1)
  • useLanguage (90-93)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Install
  • GitHub Check: Analyze (javascript)
  • GitHub Check: Type-checks, eslint, unit tests and SonarCloud
🔇 Additional comments (1)
src/layout/ImageUpload/ImageControllers.tsx (1)

1-159: LGTM overall structure and implementation.

The component structure is well-organized with:

  • Proper use of React hooks (useId, useRef, useLanguage)
  • Clear separation of concerns (handlers, conditional rendering)
  • Good keyboard accessibility for file selection
  • Appropriate prop types and TypeScript usage

Once the zoom normalization bug and reset button accessibility are addressed, this component will be production-ready.

Comment on lines 22 to 24
const CANVAS_HEIGHT = 320;
const CANVAS_WIDTH = 1000;

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Clamp zoom (avoid negative/zero) and normalize wheel delta; consider tuning sensitivity

Current logic can drive zoom ≤ 0 and feels uneven. Clamp to a sane range and normalize deltaMode for consistent behavior.

 const CANVAS_HEIGHT = 320;
 const CANVAS_WIDTH = 1000;
+const MIN_ZOOM = 0.1;
+const MAX_ZOOM = 8;
+const ZOOM_SENSITIVITY = 0.001;
   const handleWheel = useCallback(
     (e: WheelEvent) => {
       e.preventDefault();
-      onZoomChange(zoom - e.deltaY * 0.001);
+      // Normalize wheel delta across devices (pixels/lines/pages)
+      const normalizedDelta =
+        e.deltaMode === 1 ? e.deltaY * 16 : e.deltaMode === 2 ? e.deltaY * 800 : e.deltaY;
+      const next = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom - normalizedDelta * ZOOM_SENSITIVITY));
+      onZoomChange(next);
     },
     [zoom, onZoomChange],
   );

Note: You can also switch to multiplicative zoom for finer control at low zoom:
next = clamp(zoom * Math.pow(1.0015, -normalizedDelta), MIN_ZOOM, MAX_ZOOM).

Also applies to: 97-103

🤖 Prompt for AI Agents
In src/layout/ImageUpload/ImageCanvas.tsx around lines 22-24 (and the wheel
handler at 97-103), add MIN_ZOOM and MAX_ZOOM constants (e.g., 0.1 and 10) and
replace the current wheel zoom math with a normalized delta and multiplicative
clamp: compute normalizedDelta by mapping event.deltaY according to
event.deltaMode (DOM_DELTA_PIXEL=0 use as-is, DOM_DELTA_LINE multiply by ~16,
DOM_DELTA_PAGE multiply by window.innerHeight), then apply nextZoom = clamp(zoom
* Math.pow(1.0015, -normalizedDelta * SENSITIVITY), MIN_ZOOM, MAX_ZOOM); ensure
SENSITIVITY is a configurable small number to tune feel, and always set state
with the clamped nextZoom so zoom never becomes ≤0.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (2)
src/layout/ImageUpload/ImageCropper.tsx (2)

198-212: Non-null assertion fails when only storedImage exists.

Line 200 uses imageTypeRef.current!, but imageTypeRef.current is only set in handleFileUpload (line 108). When the component mounts with an existing storedImage but no new upload:

  1. imageRef.current is null
  2. storedImage exists
  3. imageTypeRef.current is null
  4. The condition (imageRef.current || storedImage) passes
  5. ImageControllers receives null as imageType, violating its type contract

Use optional chaining with a safe fallback:

       {(imageRef.current || storedImage) && (
         <ImageControllers
-          imageType={imageTypeRef.current!}
+          imageType={imageTypeRef.current ?? storedImage?.data?.contentType ?? IMAGE_TYPE}
           readOnly={readOnly}
           zoom={zoom}
           zoomLimits={{ minZoom: minAllowedZoom, maxZoom: MAX_ZOOM }}
           storedImage={storedImage}
           updateZoom={handleZoomChange}
           onSave={handleSave}
           onDelete={handleDeleteImage}
           onCancel={handleCancel}
           onFileUploaded={handleFileUpload}
           onReset={() => updateImageState({})}
         />
       )}

This ensures imageType always receives a valid string, falling back to the stored image's content type or the default IMAGE_TYPE constant.


94-116: Address memory leak and race condition in file upload.

The FileReader and Image objects created in handleFileUpload are not cleaned up. This creates two issues:

  1. Memory leak: If the component unmounts while an image is loading, the onload handlers continue to hold references and eventually fire.
  2. Race condition: If a user uploads a new file before the previous one finishes loading, both onload handlers will fire and call updateImageState, potentially with the wrong image.

Consider tracking the loading state and cleaning up:

+  const loadingImageRef = useRef<HTMLImageElement | null>(null);
+  const loadingReaderRef = useRef<FileReader | null>(null);
+
   const handleFileUpload = (file: File) => {
     const validationErrors = validateFile(file);
     setValidationErrors(validationErrors);
     if (validationErrors.length > 0) {
       return;
     }

+    // Clean up any previous loading
+    if (loadingImageRef.current) {
+      loadingImageRef.current.onload = null;
+      loadingImageRef.current.src = '';
+    }
+    if (loadingReaderRef.current) {
+      loadingReaderRef.current.onload = null;
+    }

     const reader = new FileReader();
+    loadingReaderRef.current = reader;
     reader.onload = (event) => {
       const result = event.target?.result;

       if (typeof result === 'string') {
         const img = new Image();
+        loadingImageRef.current = img;
         img.id = file.name;
         imageTypeRef.current = file.type;
         img.onload = () => {
+          // Only update if this is still the current loading image
+          if (loadingImageRef.current === img) {
             updateImageState({ minZoom: calculateMinZoom({ img, cropArea }), img });
+            loadingImageRef.current = null;
+          }
         };
         img.src = result;
       }
     };
     reader.readAsDataURL(file);
   };
+
+  // Clean up on unmount
+  useEffect(() => {
+    return () => {
+      if (loadingImageRef.current) {
+        loadingImageRef.current.onload = null;
+        loadingImageRef.current.src = '';
+      }
+      if (loadingReaderRef.current) {
+        loadingReaderRef.current.onload = null;
+      }
+    };
+  }, []);

Don't forget to import useEffect:

-import React, { useCallback, useRef, useState } from 'react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
🧹 Nitpick comments (4)
src/layout/ImageUpload/ImageCanvas/hooks/useKeyboardNavigation.tsx (1)

11-31: Consider clamping and accelerated steps (Shift+Arrow).

Ensure position is clamped in this layer or upstream to avoid moving image outside allowed bounds. Optionally support Shift+Arrow for larger step (e.g., 50px) to improve keyboard UX.

src/layout/ImageUpload/ImageCanvas/ImageCanvas.module.css (1)

1-12: Add visible focus style for keyboard users.

The canvas is focusable; provide a clear focus ring.

 .canvas {
   display: block;
   position: relative;
   left: 50%;
   transform: translateX(-50%);
   cursor: grab;
   touch-action: none;
 }
 
 .canvas:active {
   cursor: grabbing;
 }
+
+.canvas:focus-visible {
+  outline: 2px solid var(--fds-semantic-border-focus, #1a73e8);
+  outline-offset: 2px;
+}
src/layout/ImageUpload/ImageCanvas/ImageCanvas.tsx (1)

26-35: E2E under feature flag: gate tests instead of changing CI app.

To avoid breaking other PRs, gate Cypress tests on the imageUpload flag:

  • Make tests conditional: read Cypress.env('imageUpload') or app runtime flag, and skip when disabled.
  • In GH Actions for this PR, set the env to enable the flag only for these tests.
  • Alternatively, in the test, programmatically enable the flag via app config/route param/local storage if supported by the app.

This keeps CI stable while validating the feature where relevant.

src/layout/ImageUpload/ImageCropper.tsx (1)

138-148: Add error handling for failed blob creation.

When toBlob fails and returns null, the function silently returns without saving or notifying the user. This can leave users confused about whether their image was saved.

Consider adding error handling:

     cropCanvas.toBlob((blob) => {
       if (!blob) {
+        console.error('Failed to create image blob');
+        setValidationErrors(['image_upload_component.error_save_failed']);
         return;
       }

       const newFileName = getNewFileName({ fileName: img.id });
       const imageFile = new File([blob], newFileName, { type: IMAGE_TYPE });
       saveImage(imageFile);
       setValidationErrors(null);
     }, IMAGE_TYPE);

Add the corresponding translation key to your language files:

image_upload_component: {
  error_save_failed: 'Failed to save the cropped image. Please try again.',
}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6f05f4f and 07bce70.

📒 Files selected for processing (9)
  • src/layout/ImageUpload/ImageCanvas/ImageCanvas.module.css (1 hunks)
  • src/layout/ImageUpload/ImageCanvas/ImageCanvas.tsx (1 hunks)
  • src/layout/ImageUpload/ImageCanvas/ImagePreview.module.css (1 hunks)
  • src/layout/ImageUpload/ImageCanvas/ImagePreview.tsx (1 hunks)
  • src/layout/ImageUpload/ImageCanvas/hooks/useCanvasDraw.tsx (1 hunks)
  • src/layout/ImageUpload/ImageCanvas/hooks/useDragInteraction.tsx (1 hunks)
  • src/layout/ImageUpload/ImageCanvas/hooks/useKeyboardNavigation.tsx (1 hunks)
  • src/layout/ImageUpload/ImageCanvas/hooks/useZoomInteraction.tsx (1 hunks)
  • src/layout/ImageUpload/ImageCropper.tsx (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • src/layout/ImageUpload/ImageCanvas/ImagePreview.module.css
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx}: Avoid using any and unnecessary type casts (as Type) in TypeScript; prefer precise typings and refactor existing casts/anys
For TanStack Query, use objects to manage query keys and functions, and centralize shared options via queryOptions

Files:

  • src/layout/ImageUpload/ImageCanvas/ImagePreview.tsx
  • src/layout/ImageUpload/ImageCanvas/hooks/useKeyboardNavigation.tsx
  • src/layout/ImageUpload/ImageCanvas/ImageCanvas.tsx
  • src/layout/ImageUpload/ImageCanvas/hooks/useDragInteraction.tsx
  • src/layout/ImageUpload/ImageCanvas/hooks/useCanvasDraw.tsx
  • src/layout/ImageUpload/ImageCanvas/hooks/useZoomInteraction.tsx
  • src/layout/ImageUpload/ImageCropper.tsx
**/*.module.css

📄 CodeRabbit inference engine (CLAUDE.md)

Use CSS Modules for component styling and follow existing patterns in *.module.css files

Files:

  • src/layout/ImageUpload/ImageCanvas/ImageCanvas.module.css
🧬 Code graph analysis (6)
src/layout/ImageUpload/ImageCanvas/ImagePreview.tsx (2)
src/features/attachments/index.ts (1)
  • UploadedAttachment (20-20)
src/features/language/useLanguage.ts (1)
  • useLanguage (90-93)
src/layout/ImageUpload/ImageCanvas/hooks/useKeyboardNavigation.tsx (1)
src/layout/ImageUpload/imageUploadUtils.ts (1)
  • Position (6-6)
src/layout/ImageUpload/ImageCanvas/ImageCanvas.tsx (8)
src/layout/ImageUpload/imageUploadUtils.ts (2)
  • Position (6-6)
  • CropArea (15-15)
src/layout/ImageUpload/useImageFile.tsx (1)
  • useImageFile (17-52)
src/features/language/useLanguage.ts (1)
  • useLanguage (90-93)
src/layout/ImageUpload/ImageCanvas/hooks/useCanvasDraw.tsx (1)
  • useCanvasDraw (20-51)
src/layout/ImageUpload/ImageCanvas/hooks/useZoomInteraction.tsx (1)
  • useZoomInteraction (10-28)
src/layout/ImageUpload/ImageCanvas/hooks/useDragInteraction.tsx (1)
  • useDragInteraction (11-40)
src/layout/ImageUpload/ImageCanvas/hooks/useKeyboardNavigation.tsx (1)
  • useKeyboardNavigation (11-32)
src/layout/ImageUpload/ImageCanvas/ImagePreview.tsx (1)
  • ImagePreview (14-37)
src/layout/ImageUpload/ImageCanvas/hooks/useDragInteraction.tsx (1)
src/layout/ImageUpload/imageUploadUtils.ts (1)
  • Position (6-6)
src/layout/ImageUpload/ImageCanvas/hooks/useCanvasDraw.tsx (1)
src/layout/ImageUpload/imageUploadUtils.ts (5)
  • Position (6-6)
  • CropArea (15-15)
  • imagePlacement (61-68)
  • cropAreaPlacement (73-77)
  • drawCropArea (86-94)
src/layout/ImageUpload/ImageCropper.tsx (6)
src/layout/ImageUpload/imageUploadUtils.ts (10)
  • CropArea (15-15)
  • Position (6-6)
  • calculateMinZoom (125-126)
  • constrainToArea (41-52)
  • imagePlacement (61-68)
  • validateFile (128-151)
  • cropAreaPlacement (73-77)
  • drawCropArea (86-94)
  • getNewFileName (170-173)
  • IMAGE_TYPE (4-4)
src/layout/ImageUpload/useImageFile.tsx (1)
  • useImageFile (17-52)
src/layout/ImageUpload/ImageDropzone.tsx (1)
  • ImageDropzone (18-58)
src/layout/ImageUpload/ImageCanvas/ImageCanvas.tsx (1)
  • ImageCanvas (26-65)
src/layout/ImageUpload/ImageControllers.tsx (1)
  • ImageControllers (26-159)
src/features/language/Lang.tsx (1)
  • Lang (15-23)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Analyze (javascript)
  • GitHub Check: Type-checks, eslint, unit tests and SonarCloud
  • GitHub Check: Install

Comment on lines +20 to +50
export const useCanvasDraw = ({ canvasRef, imageRef, zoom, position, cropArea }: UseCanvasDrawProps) => {
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
const img = imageRef.current;

if (!canvas || !img?.complete || !ctx) {
return;
}

ctx.clearRect(0, 0, canvas.width, canvas.height);
const { imgX, imgY, scaledWidth, scaledHeight } = imagePlacement({ canvas, img, zoom, position });

ctx.drawImage(img, imgX, imgY, scaledWidth, scaledHeight);
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.save();

const { cropAreaX, cropAreaY } = cropAreaPlacement({ canvas, cropArea });
drawCropArea({ ctx, x: cropAreaX, y: cropAreaY, cropArea });
ctx.clip();
ctx.drawImage(img, imgX, imgY, scaledWidth, scaledHeight);
ctx.restore();

drawCropArea({ ctx, x: cropAreaX, y: cropAreaY, cropArea });
ctx.strokeStyle = 'white';
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
ctx.stroke();
ctx.setLineDash([]);
}, [canvasRef, imageRef, zoom, position, cropArea]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Draw after image load; current effect can miss initial render.

The effect exits when !img?.complete and never re-runs on load, so the canvas can stay blank until another state change occurs. Register a load listener and draw once the image finishes loading.

 export const useCanvasDraw = ({ canvasRef, imageRef, zoom, position, cropArea }: UseCanvasDrawProps) => {
   useEffect(() => {
-    const canvas = canvasRef.current;
-    const ctx = canvas?.getContext('2d');
-    const img = imageRef.current;
-
-    if (!canvas || !img?.complete || !ctx) {
-      return;
-    }
-
-    ctx.clearRect(0, 0, canvas.width, canvas.height);
-    const { imgX, imgY, scaledWidth, scaledHeight } = imagePlacement({ canvas, img, zoom, position });
-
-    ctx.drawImage(img, imgX, imgY, scaledWidth, scaledHeight);
-    ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
-    ctx.fillRect(0, 0, canvas.width, canvas.height);
-    ctx.save();
-
-    const { cropAreaX, cropAreaY } = cropAreaPlacement({ canvas, cropArea });
-    drawCropArea({ ctx, x: cropAreaX, y: cropAreaY, cropArea });
-    ctx.clip();
-    ctx.drawImage(img, imgX, imgY, scaledWidth, scaledHeight);
-    ctx.restore();
-
-    drawCropArea({ ctx, x: cropAreaX, y: cropAreaY, cropArea });
-    ctx.strokeStyle = 'white';
-    ctx.lineWidth = 2;
-    ctx.setLineDash([5, 5]);
-    ctx.stroke();
-    ctx.setLineDash([]);
+    const canvas = canvasRef.current;
+    const ctx = canvas?.getContext('2d');
+    const img = imageRef.current;
+    if (!canvas || !ctx || !img) {
+      return;
+    }
+
+    const draw = () => {
+      if (!img.complete) return;
+      ctx.clearRect(0, 0, canvas.width, canvas.height);
+      const { imgX, imgY, scaledWidth, scaledHeight } = imagePlacement({ canvas, img, zoom, position });
+
+      ctx.drawImage(img, imgX, imgY, scaledWidth, scaledHeight);
+      ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
+      ctx.fillRect(0, 0, canvas.width, canvas.height);
+      ctx.save();
+
+      const { cropAreaX, cropAreaY } = cropAreaPlacement({ canvas, cropArea });
+      drawCropArea({ ctx, x: cropAreaX, y: cropAreaY, cropArea });
+      ctx.clip();
+      ctx.drawImage(img, imgX, imgY, scaledWidth, scaledHeight);
+      ctx.restore();
+
+      drawCropArea({ ctx, x: cropAreaX, y: cropAreaY, cropArea });
+      ctx.strokeStyle = 'white';
+      ctx.lineWidth = 2;
+      ctx.setLineDash([5, 5]);
+      ctx.stroke();
+      ctx.setLineDash([]);
+    };
+
+    if (img.complete) {
+      draw();
+    } else {
+      img.addEventListener('load', draw, { once: true });
+    }
+
+    return () => {
+      img.removeEventListener('load', draw);
+    };
   }, [canvasRef, imageRef, zoom, position, cropArea]);
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const useCanvasDraw = ({ canvasRef, imageRef, zoom, position, cropArea }: UseCanvasDrawProps) => {
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
const img = imageRef.current;
if (!canvas || !img?.complete || !ctx) {
return;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
const { imgX, imgY, scaledWidth, scaledHeight } = imagePlacement({ canvas, img, zoom, position });
ctx.drawImage(img, imgX, imgY, scaledWidth, scaledHeight);
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.save();
const { cropAreaX, cropAreaY } = cropAreaPlacement({ canvas, cropArea });
drawCropArea({ ctx, x: cropAreaX, y: cropAreaY, cropArea });
ctx.clip();
ctx.drawImage(img, imgX, imgY, scaledWidth, scaledHeight);
ctx.restore();
drawCropArea({ ctx, x: cropAreaX, y: cropAreaY, cropArea });
ctx.strokeStyle = 'white';
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
ctx.stroke();
ctx.setLineDash([]);
}, [canvasRef, imageRef, zoom, position, cropArea]);
export const useCanvasDraw = ({ canvasRef, imageRef, zoom, position, cropArea }: UseCanvasDrawProps) => {
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
const img = imageRef.current;
if (!canvas || !ctx || !img) {
return;
}
const draw = () => {
if (!img.complete) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
const { imgX, imgY, scaledWidth, scaledHeight } =
imagePlacement({ canvas, img, zoom, position });
ctx.drawImage(img, imgX, imgY, scaledWidth, scaledHeight);
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.save();
const { cropAreaX, cropAreaY } = cropAreaPlacement({ canvas, cropArea });
drawCropArea({ ctx, x: cropAreaX, y: cropAreaY, cropArea });
ctx.clip();
ctx.drawImage(img, imgX, imgY, scaledWidth, scaledHeight);
ctx.restore();
drawCropArea({ ctx, x: cropAreaX, y: cropAreaY, cropArea });
ctx.strokeStyle = 'white';
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
ctx.stroke();
ctx.setLineDash([]);
};
if (img.complete) {
draw();
} else {
img.addEventListener('load', draw, { once: true });
}
return () => {
img.removeEventListener('load', draw);
};
}, [canvasRef, imageRef, zoom, position, cropArea]);
};
🤖 Prompt for AI Agents
In src/layout/ImageUpload/ImageCanvas/hooks/useCanvasDraw.tsx around lines 20 to
50, the effect bails out when the image is not yet complete so the canvas may
never be drawn on initial load; add a load event listener on imageRef.current
that calls the same draw routine when the image finishes loading, and if the
image is already complete run the draw immediately; ensure you register the
listener inside the effect, remove it in the cleanup to avoid leaks, and keep
the existing early-return logic for missing refs/ctx while factoring the drawing
logic into a function that both the immediate path and the load handler call.

Comment on lines +10 to +17
export const useZoomInteraction = ({ canvasRef, zoom, onZoomChange }: UseZoomInteractionProps) => {
const handleWheel = useCallback(
(e: WheelEvent) => {
e.preventDefault();
onZoomChange(zoom - e.deltaY * 0.001);
},
[zoom, onZoomChange],
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Clamp zoom to safe bounds.

Current logic allows zero/negative or extreme zoom values, causing rendering glitches or inverted images. Clamp to a reasonable range.

 export const useZoomInteraction = ({ canvasRef, zoom, onZoomChange }: UseZoomInteractionProps) => {
   const handleWheel = useCallback(
     (e: WheelEvent) => {
       e.preventDefault();
-      onZoomChange(zoom - e.deltaY * 0.001);
+      const MIN_ZOOM = 0.1;
+      const MAX_ZOOM = 10;
+      const next = zoom - e.deltaY * 0.001;
+      const clamped = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, next));
+      onZoomChange(clamped);
     },
     [zoom, onZoomChange],
   );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const useZoomInteraction = ({ canvasRef, zoom, onZoomChange }: UseZoomInteractionProps) => {
const handleWheel = useCallback(
(e: WheelEvent) => {
e.preventDefault();
onZoomChange(zoom - e.deltaY * 0.001);
},
[zoom, onZoomChange],
);
export const useZoomInteraction = ({ canvasRef, zoom, onZoomChange }: UseZoomInteractionProps) => {
const handleWheel = useCallback(
(e: WheelEvent) => {
e.preventDefault();
const MIN_ZOOM = 0.1;
const MAX_ZOOM = 10;
const next = zoom - e.deltaY * 0.001;
const clamped = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, next));
onZoomChange(clamped);
},
[zoom, onZoomChange],
);
🤖 Prompt for AI Agents
In src/layout/ImageUpload/ImageCanvas/hooks/useZoomInteraction.tsx around lines
10 to 17, the wheel handler computes a new zoom that can become zero, negative,
or extremely large; change it to clamp the computed zoom to safe bounds (e.g.
minZoom = 0.1 and maxZoom = 4.0) before calling onZoomChange. Compute const next
= zoom - e.deltaY * 0.001; const clamped = Math.min(maxZoom, Math.max(minZoom,
next)); then call onZoomChange(clamped) (keep e.preventDefault() and the
useCallback deps).

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (8)
src/layout/ImageUpload/imageUploadUtils.ts (5)

41-52: Use natural image dimensions and guard against zero.

This duplicates an earlier concern: image.width/height are CSS pixels; use naturalWidth/naturalHeight for pixel-accurate canvas calculations. Also guard against zero dimensions to prevent Infinity/NaN.

Based on past review comments.


61-68: Use natural image dimensions.

Same issue as constrainToArea: use img.naturalWidth/naturalHeight instead of img.width/height for accurate pixel calculations.

Based on past review comments.


105-122: Guard zoom calculations against invalid inputs.

This duplicates an earlier concern: Math.log with non-positive values yields -Infinity/NaN. Guard inputs and clamp outputs to prevent propagating invalid numbers.

Based on past review comments.


125-126: Use natural image dimensions.

Same issue: use img.naturalWidth/naturalHeight instead of img.width/height.

Based on past review comments.


184-204: Calculation logic is sound, but use natural dimensions.

The zoom centering calculation correctly maintains the image center point during zoom transitions. However, this also uses img.width/height instead of naturalWidth/naturalHeight as flagged in earlier comments.

Based on past review comments for the dimension issue.

src/layout/ImageUpload/ImageCropper.tsx (3)

77-99: Memory leak: Image object not cleaned up.

This duplicates an earlier concern: the Image object created here is not cleaned up if the component unmounts or a new file is uploaded before loading completes. Track loading state and cancel in-flight loads.

Based on past review comments.


101-131: Handle toBlob failures with user feedback.

This duplicates an earlier concern: the toBlob callback should log errors and show user feedback when blob creation fails, rather than silently returning.

Based on past review comments.


181-195: Non-null assertion can fail when restoring saved image.

This duplicates an earlier concern: imageTypeRef.current! can be null when the component mounts with an existing storedImage (no new upload). Use a fallback like imageTypeRef.current ?? storedImage?.data?.contentType ?? IMAGE_TYPE.

Based on past review comments.

🧹 Nitpick comments (1)
src/layout/ImageUpload/imageUploadUtils.ts (1)

73-77: Consider extracting center calculation constant (optional).

The division by 2 finds the center point. While clear in context, you could optionally extract const CENTER_DIVISOR = 2 if you want to make the centering intent more explicit throughout the file.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 07bce70 and 8f8ea3d.

📒 Files selected for processing (3)
  • src/layout/ImageUpload/ImageCropper.tsx (1 hunks)
  • src/layout/ImageUpload/imageUploadUtils.test.tsx (1 hunks)
  • src/layout/ImageUpload/imageUploadUtils.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/layout/ImageUpload/imageUploadUtils.test.tsx
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx}: Avoid using any and unnecessary type casts (as Type) in TypeScript; prefer precise typings and refactor existing casts/anys
For TanStack Query, use objects to manage query keys and functions, and centralize shared options via queryOptions

Files:

  • src/layout/ImageUpload/ImageCropper.tsx
  • src/layout/ImageUpload/imageUploadUtils.ts
🧬 Code graph analysis (2)
src/layout/ImageUpload/ImageCropper.tsx (7)
src/layout/ImageUpload/imageUploadUtils.ts (11)
  • CropArea (15-15)
  • Position (6-6)
  • calculateMinZoom (125-126)
  • constrainToArea (41-52)
  • calculatePositionForZoom (184-204)
  • validateFile (128-151)
  • imagePlacement (61-68)
  • cropAreaPlacement (73-77)
  • drawCropArea (86-94)
  • getNewFileName (170-173)
  • IMAGE_TYPE (4-4)
src/layout/ImageUpload/useImageFile.tsx (1)
  • useImageFile (17-52)
src/layout/ImageUpload/ImageDropzone.tsx (1)
  • ImageDropzone (18-58)
src/app-components/Card/Card.tsx (1)
  • AppCard (20-54)
src/layout/ImageUpload/ImageCanvas/ImageCanvas.tsx (1)
  • ImageCanvas (26-65)
src/layout/ImageUpload/ImageControllers.tsx (1)
  • ImageControllers (26-159)
src/features/language/Lang.tsx (1)
  • Lang (15-23)
src/layout/ImageUpload/imageUploadUtils.ts (1)
src/types/shared.ts (1)
  • IDataType (56-68)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Type-checks, eslint, unit tests and SonarCloud
  • GitHub Check: Analyze (javascript)
  • GitHub Check: Install
🔇 Additional comments (12)
src/layout/ImageUpload/imageUploadUtils.ts (6)

1-32: LGTM!

The type definitions and getCropArea function are well-structured. The logic correctly constrains circle crops to squares by using the minimum dimension.


86-94: LGTM!

The canvas path drawing correctly handles both rectangle and circle crop shapes using standard geometry.


128-151: LGTM!

The file validation logic correctly checks size (10 MB limit) and type (image/*), with clear error messaging using language keys.


153-156: LGTM!

The animation file detection correctly identifies GIF, APNG, and WebP formats with case-insensitive comparison.


163-168: LGTM!

The content type validation correctly handles both unrestricted uploads (empty array) and validates PNG support when restrictions are defined.


170-173: LGTM!

The filename transformation correctly strips the original extension and replaces it with .png.

src/layout/ImageUpload/ImageCropper.tsx (6)

1-28: LGTM!

The component interface is well-defined with all necessary props including readOnly for proper form state handling.


32-42: LGTM!

The component initialization properly sets up refs, state, and calculates minimum zoom with a safe fallback.


43-75: LGTM!

The position and zoom handlers correctly constrain values to valid ranges and are properly memoized with appropriate dependencies.


133-148: LGTM!

The cleanup and state update handlers correctly reset the component state for delete, cancel, and reset operations.


150-162: LGTM!

The initial upload UI correctly passes through readOnly and error states to the dropzone component.


201-214: LGTM!

The validation messages component correctly handles null checks and renders localized error messages.

Copy link

Quality Gate Failed Quality Gate failed

Failed conditions
C Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-ignore This PR is a new feature and should not be cherry-picked onto release branches kind/product-feature Pull requests containing new features squad/utforming Issues that belongs to the named squad.

Projects

Status: 🔎 In review

Development

Successfully merging this pull request may close these issues.

4 participants